#!/usr/bin/env python3
"""sievenss - read passwords from Mozilla password databases"""

#
# Copyright 2024  Odin Kroeger
#
# This file is part of SieveManager.
#
# SieveManager is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
#
# SieveManager is distributed in the hope that it will be useful,
# but WITHOUT ALL WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public
# License along with SieveManager. If not, see
# <https://www.gnu.org/licenses/>.
#


#
# Modules
#

import json
import logging
import platform
import re

from errno import ENOENT
from fnmatch import fnmatch
from getopt import GetoptError, getopt
from itertools import chain, product
from os import path, strerror
from typing import Final, Iterator, Optional, NamedTuple, NoReturn
from signal import SIGHUP, SIGINT, SIGTERM
from subprocess import PIPE, CalledProcessError, Popen  # nosec B404
# pylint: disable=redefined-builtin
from sys import argv, exit
from urllib.parse import urlparse

from sievemgr import (HOME, XDG_CONFIG_HOME, ConfigError, SignalCaught,
                      SieveConfig, error, showhelp, readdir)


#
# Metadata
#

__version__ = '0.1'
__author__ = 'Odin Kroeger'
__copyright__ = '2024 Odin Kroeger'
__license__ = 'GPLv3+'


#
# Globals
#

DATADIRS: Final[tuple[str, ...]] = (
    f'{HOME}/Library/Application Support/{{}}',
    f'{HOME}/Library/{{app}}'
) if platform.system() == 'Darwin' else (
    f'{XDG_CONFIG_HOME}/mozilla/{{}}',
    f'{HOME}/.mozilla/{{}}',
    f'{HOME}/.{{}}'
)
"""Application data directories. ``{}`` is replaced by the application name."""

APPS: Final = ('Thunderbird', 'SeaMonkey', 'PostboxApp',
               'Firefox', 'LibreWolf', 'Waterfox')
"""Applications to look for."""


#
# Classes
#

class Account(NamedTuple):
    """NSS account."""
    host: str
    login: str
    passwd: str


class PasswordStore():
    """NSS password store."""

    def __init__(self, datadir: str):
        """Create a :class:`PasswordStore` object.

        `datadir` can be the filename of:

        * a Mozilla data directory
          (e.g., :file:`~/.mozilla/thunderbird`),
        * a :file:`Profiles` directory
          (e.g., :file:`~/.mozilla/thunderbird/Profiles), or
        * a particular profile directory
          (e.g. :file:`~/.mozilla/thunderbird/Profiles/abcd1234`).

        If `datadir` names a data directory or a :file:`Profiles` directory,
        the default profile is read.
        """
        dbdir = self.getprofiledir(datadir)
        if not dbdir:
            raise ValueError(f'{datadir}: not a Mozilla data directory')
        with open(path.join(dbdir, 'logins.json'), encoding='utf8') as logins:
            self.accounts = json.load(logins)
        self.dbdir = dbdir

    def getaccounts(self, host: Optional[str] = None,
                    login: Optional[str] = None) -> Iterator[Account]:
        """Search for accounts matching `host` and `login`."""
        with MyPopen(['pwdecrypt', '-d', self.dbdir],
                     stdin=PIPE, stdout=PIPE) as pwdecrypt:
            for account in self.accounts['logins']:
                hostname = account['hostname']
                try:
                    if host is not None:
                        url = urlparse(hostname)
                        if not fnmatch(url.hostname, host):
                            continue
                    pwdecrypt.writeline(hostname)
                    pwdecrypt.writeline(account['encryptedUsername'])
                    pwdecrypt.writeline(account['encryptedPassword'])
                except KeyError:
                    continue
            pwdecrypt.stdin.close()  # type: ignore[union-attr]

            isdecrypted = re.compile(r'decrypted: "(.*)"$', re.IGNORECASE)
            while ((hostname := pwdecrypt.readline())
                   and (logmatch := isdecrypted.match(pwdecrypt.readline()))
                   and (pwdmatch := isdecrypted.match(pwdecrypt.readline()))):
                if login is None or logmatch[1] == login:
                    yield Account(hostname, logmatch[1], pwdmatch[1])

    @classmethod
    def getprofiledir(cls, basedir: str, depth: int = 2) -> Optional[str]:
        """Search for :file:`logins.json` in `basedir`.

        `basedir` can be the filename of:

        * a Mozilla data directory
          (e.g., :file:`~/.mozilla/thunderbird`),
        * a :file:`Profiles` directory
          (e.g., :file:`~/.mozilla/thunderbird/Profiles), or
        * a particular profile directory
          (e.g. :file:`~/.mozilla/thunderbird/Profiles/abcd1234`).
        """
        if path.exists(path.join(basedir, 'logins.json')):
            return basedir
        # pylint: disable=bad-indentation
        for dirname in readdir(basedir, path.isdir):
            if (depth > 0
                and dirname.endswith('.default')
                and (dbdir := cls.getprofiledir(dirname, depth=0))):
                    return dbdir
            if (depth > 1
                and path.split(dirname)[-1].casefold() == 'profiles'
                and (dbdir := cls.getprofiledir(dirname, depth=1))):
                    return dbdir
        return None


class MyPopen(Popen):
    """:class:`Popen` subclass that provides convenience functions."""

    def __init__(self, *args, encoding: str = 'utf8', **kwargs):
        super().__init__(*args, encoding=encoding, **kwargs)

    def readline(self) -> str:
        """Read a line from pipe."""
        return self.stdout.readline().strip()  # type: ignore[union-attr]

    def writeline(self, line: str):
        """Write `line` to the pipe."""
        print(line, file=self.stdin)


#
# Functions
#

def showversion() -> NoReturn:
    """Print version information and :func:`exit`."""
    print(f'sievenss (SieveManager) {__version__}\n'
          f'Copyright {__copyright__}')
    exit()


#
# Main
#

# pylint: disable=too-many-branches, too-many-statements
@SignalCaught.catch(SIGHUP, SIGINT, SIGTERM)
def main() -> NoReturn:
    """sievenss - read passwords from Mozilla password databases

    Usage: sievenss [-d datadir] [-p] [host] [login]

    Options:
        -d datadir  Read passwords from datadir.
        -p          Print the first matching password only.

        See sievenss(1) for a complete list.

    Report bugs to: <https://github.com/odkr/sievemgr/issues>
    Home page: <https://odkr.codeberg.page/sievemgr>
    """
    progname = path.basename(argv[0])
    logging.basicConfig(format=f'{progname}: %(message)s')

    # Options
    try:
        opts, args = getopt(argv[1:], 'Vcd:hopqv', ['help', 'version'])
    except GetoptError as err:
        error('%s', err, status=2)

    conf = SieveConfig()
    configfiles = []
    datadir = None
    pwdonly = False
    volume = 0
    try:
        for opt, arg in opts:
            if opt in ('-h', '--help'):
                showhelp(main)
            elif opt in ('-V', '--version'):
                showversion()
            elif opt == '-c':
                configfiles.append(arg)
            elif opt == '-d':
                datadir = arg
            elif opt == '-o':
                conf.parse(arg)
            elif opt == '-p':
                pwdonly = True
            elif opt == '-q':
                volume -= 1
            elif opt == '-v':
                volume += 1
    except (AttributeError, TypeError, ValueError) as err:
        error('option %s: %s', opt, err, status=2)

    # Arguments
    host = args.pop(0) if args else None
    login = args.pop(0) if args else None

    # Configuration
    try:
        conf = SieveConfig().fromfiles(*configfiles) | conf
    except FileNotFoundError as err:
        error('%s: %s', err.filename, strerror(err.errno))
    except ConfigError as err:
        error('%s', err)
    if host:
        conf.loadaccount(host=host, login=login)

    # Logging
    conf.verbosity = conf.verbosity.fromdelta(volume)
    logger = logging.getLogger()
    logger.setLevel(conf.verbosity)

    # Password store
    if datadir is None:
        apps = chain(*((app, app.casefold()) for app in APPS))
        for appdir, app in product(DATADIRS, apps):
            datadir = path.expanduser(appdir.format(app))
            try:
                pwdstore = PasswordStore(datadir)
                logging.info('reading passwords from %s',
                             re.sub(f'^{re.escape(HOME)}/', '~/', datadir))
                break
            except (FileNotFoundError, ValueError):
                continue
            except MemoryError as err:
                error('%s', err)
            except OSError as err:
                error('%s', strerror(err.errno))
            break
    else:
        try:
            pwdstore = PasswordStore(datadir)
        except FileNotFoundError as err:
            error('%s: %s', err.filename, strerror(ENOENT))
        except MemoryError as err:
            error('%s', err)
        except OSError as err:
            error('%s', strerror(err.errno))
        except ValueError as err:
            error('%s', err)

    # Print accounts
    try:
        accounts = pwdstore.getaccounts(host, login)
    except CalledProcessError as err:
        error('%s: exited with status %d', err.cmd[0], err.returncode)
    except OSError as err:
        error('%s', strerror(err.errno))
    for account in accounts:
        if pwdonly:
            print(account.passwd)
            break
        print(*account)
    exit()


#
# Boiler plate
#

if __name__ == '__main__':
    main()
